Lesson Plan JS -> TS UI conversion and implementation enhancements - #8559
Merged
Conversation
Contributor
There was a problem hiding this comment.
Pull request overview
Modernizes the lesson-plan frontend with TypeScript, Redux Toolkit, locally controlled dialogs, and serialized debounced saves.
Changes:
- Converts lesson-plan components, reducers, and tests to TypeScript.
- Adds debounced per-row saves and saving indicators.
- Moves form state out of Redux and adopts
createSlice.
Reviewed changes
Copilot reviewed 48 out of 49 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
client/app/bundles/course/lesson-plan/types.ts |
Adds shared lesson-plan types. |
client/app/bundles/course/lesson-plan/store.ts |
Combines Redux Toolkit reducers/actions. |
client/app/bundles/course/lesson-plan/reducers/utils.ts |
Types grouping and visibility utilities. |
client/app/bundles/course/lesson-plan/reducers/milestoneForm.js |
Removes milestone form reducer. |
client/app/bundles/course/lesson-plan/reducers/lessonPlan.ts |
Adds typed lesson-plan slice. |
client/app/bundles/course/lesson-plan/reducers/lessonPlan.js |
Removes legacy reducer. |
client/app/bundles/course/lesson-plan/reducers/flags.ts |
Adds typed flags slice. |
client/app/bundles/course/lesson-plan/reducers/flags.js |
Removes legacy flags reducer. |
client/app/bundles/course/lesson-plan/reducers/eventForm.js |
Removes event form reducer. |
client/app/bundles/course/lesson-plan/pages/LessonPlanShow/MilestoneAdminTools.tsx |
Converts milestone tools and owns dialog state. |
client/app/bundles/course/lesson-plan/pages/LessonPlanShow/MilestoneAdminTools.jsx |
Removes legacy component. |
client/app/bundles/course/lesson-plan/pages/LessonPlanShow/LessonPlanItem/AdminTools.tsx |
Converts event tools and owns dialog state. |
client/app/bundles/course/lesson-plan/pages/LessonPlanShow/LessonPlanItem/AdminTools.jsx |
Removes legacy component. |
client/app/bundles/course/lesson-plan/pages/LessonPlanShow/LessonPlanItem/__test__/AdminTools.test.tsx |
Updates event tool tests. |
client/app/bundles/course/lesson-plan/pages/LessonPlanShow/__test__/MilestoneAdminTools.test.tsx |
Updates milestone tool tests. |
client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/types.ts |
Defines save context. |
client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/MilestoneRow.tsx |
Adds serialized milestone saving. |
client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/MilestoneRow.jsx |
Removes legacy milestone row. |
client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/ItemRow/PublishedCell.tsx |
Types and disables publication toggle. |
client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/ItemRow/PublishedCell.jsx |
Removes legacy cell. |
client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/ItemRow/index.tsx |
Adds merged, debounced item saves. |
client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/ItemRow/index.jsx |
Removes legacy item row. |
client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/ItemRow/DateCell.tsx |
Types date updates and shifting. |
client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/ItemRow/DateCell.jsx |
Removes legacy date cell. |
client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/index.tsx |
Converts edit page. |
client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/index.jsx |
Removes legacy edit page. |
client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/__test__/MilestoneRow.test.tsx |
Tests debounced milestone updates. |
client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/__test__/MilestoneRow.test.jsx |
Removes legacy tests. |
client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/__test__/ItemRow.test.tsx |
Tests item autosave behavior. |
client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/__test__/ItemRow.test.jsx |
Removes legacy tests. |
client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/__test__/index.test.tsx |
Converts edit-page test. |
client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/__test__/index.test.jsx |
Removes legacy test. |
client/app/bundles/course/lesson-plan/operations.ts |
Dispatches slice actions and reports form success. |
client/app/bundles/course/lesson-plan/containers/MilestoneFormDialog/index.tsx |
Makes milestone dialog controlled. |
client/app/bundles/course/lesson-plan/containers/MilestoneFormDialog/index.jsx |
Removes Redux-connected dialog. |
client/app/bundles/course/lesson-plan/containers/LessonPlanLayout/NewMilestoneButton.tsx |
Owns milestone creation dialog. |
client/app/bundles/course/lesson-plan/containers/LessonPlanLayout/NewMilestoneButton.jsx |
Removes legacy button. |
client/app/bundles/course/lesson-plan/containers/LessonPlanLayout/NewEventButton.tsx |
Owns event creation dialog. |
client/app/bundles/course/lesson-plan/containers/LessonPlanLayout/NewEventButton.jsx |
Removes legacy button. |
client/app/bundles/course/lesson-plan/containers/LessonPlanLayout/index.tsx |
Converts layout and removes global dialogs. |
client/app/bundles/course/lesson-plan/containers/LessonPlanLayout/index.jsx |
Removes legacy layout. |
client/app/bundles/course/lesson-plan/containers/LessonPlanLayout/EnterEditModeButton.tsx |
Updates translation handling. |
client/app/bundles/course/lesson-plan/containers/LessonPlanLayout/__test__/NewMilestoneButton.test.tsx |
Updates milestone creation test. |
client/app/bundles/course/lesson-plan/containers/LessonPlanLayout/__test__/NewEventButton.test.tsx |
Updates event creation test. |
client/app/bundles/course/lesson-plan/containers/LessonPlanLayout/__test__/index.test.tsx |
Adds layout fetch coverage. |
client/app/bundles/course/lesson-plan/containers/EventFormDialog/index.tsx |
Makes event dialog controlled. |
client/app/bundles/course/lesson-plan/containers/EventFormDialog/index.jsx |
Removes Redux-connected dialog. |
client/app/bundles/course/lesson-plan/constants.ts |
Retains typed field constants. |
client/app/bundles/course/lesson-plan/constants.js |
Removes legacy action constants. |
Suppressed comments (2)
client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/ItemRow/index.tsx:136
- This guard compares only with the last queued value, not with the saved baseline. Before the debounce fires, editing
A → B → AleavesAinpendingRefand sends a no-op PATCH, contrary to the stated requirement that reverting to the saved value sends nothing. Remove that field from the pending payload when it returns to the saved value, while still queuing a revert if a different value for that field is already in flight.
const latest =
key in latestValuesRef.current
? latestValuesRef.current[key]
: (savedValues[key] as ItemValue);
if (sameValue(latest, value)) return acc;
return { ...acc, [key]: value };
client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/MilestoneRow.tsx:116
- The milestone queue has the same saved-value gap: before the debounce fires, changing
A → B → Areplaces the pending update withAand still sends a no-op PATCH. The described dedupe behavior requires cancelling the pending update when the value returns to the saved baseline, unless the differing value has already been sent in flight.
const latest =
latestValueRef.current === undefined ? startAt : latestValueRef.current;
if (sameDate(latest, newDate)) return;
latestValueRef.current = newDate;
pendingRef.current = { startAt: newDate, setError };
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
adi-herwana-nus
force-pushed
the
adi/lesson-plan-fixes-updates
branch
from
August 27, 2026 08:57
c1c32d5 to
a20834e
Compare
adi-herwana-nus
force-pushed
the
adi/lesson-plan-fixes-updates
branch
from
August 27, 2026 09:09
a20834e to
fd8b0d0
Compare
- convert LessonPlanEdit and subpages to typescript - add debouncing / request batching logic to prevent duplicate requests / personalized timeline updates
…ms to typescript - migrate onSubmit() hook from redux store to parent components - remove obsolete javascript reducers (information moved to component state)
adi-herwana-nus
force-pushed
the
adi/lesson-plan-fixes-updates
branch
from
August 27, 2026 13:25
fd8b0d0 to
9857d70
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes the client-side cause of a production incident where two students could no longer finalise any
submission in their course, and converts the lesson plan bundle it lives in to TypeScript along the way.
The lesson plan editor sent a PATCH on every datetime change, with no debouncing and no waiting for a
prior request to finish. Two of those requests overlapping enqueued two concurrent
CoursewidePersonalizedTimelineUpdateJobruns, which raced each other into creating duplicateCourse::PersonalTimerows — after which every personalisation run for those students raisedValidation failed: Course user has already been takenand rolled back their submission.Typing a date passes through valid intermediate values — keying
1then5for 15:00 yields 01:00first — and each valid value fired
onChange. The only guard,sameDate(oldDate, newDate)inDateCell, compared againstprops.fieldValue, which is the Redux value and only catches up afterthe request resolves. So a second event inside the ~0.3s round trip compared against a stale value,
passed, and dispatched.
The client no longer emits concurrent writes for the same lesson plan item, so this trigger
is closed. Further mitigation work on the backend side of the incident is planned.
Key changes
1. Debounced, serialised saves (
ItemRow,MilestoneRow)Modelled on the submission answers autosave loop, with one deliberate difference.
FIELD_LONG_DEBOUNCE_DELAY_MS), so a typed date collapses into one request.than three. This matters more than the debounce alone: the request axis is per item, so merging is
what keeps it to one job per edit.
completion.
request may have moved the saved state on by then; after a failure it will not have.
LoadingIndicatorbeside the row title from first keystroke until the save resolves, and thepublished toggle is disabled while saving.
The answers loop uses a
clientVersionlogical clock to let overlapping saves reconcile afterwards.That is right for a text editor and wrong here — we want no overlap. Serialising per row gives the
stronger guarantee with less state, and avoids a latent bug the version approach would have needed to
handle:
itemUpdatedwrites client-supplied values into the store, so two responses arriving out oforder would leave the grid showing a value the server does not have.
2. Edits the editor holds back
Two kinds of edit are never sent, for different reasons:
start_atis unfinished input rather than an edit — the user is part-way throughretyping a date. Nothing is queued and nothing is said, because nothing has gone wrong yet.
(
Course::ReferenceTime#start_at_cannot_be_after_end_at).DateCellchecks the start/end pair asthe edit would leave it and, if it is out of order, shows the error on the field and returns before
queueing — so no request goes out and no debounce is running until the user corrects it. Editing
start_atcannot trigger this, because the end dates shift with it.The message is
formTranslations.startEndDateValidationError, which the event form's yup schemaalready uses for the same rule, so the inline grid and the dialog report identically and there is no
new string to translate.
bonus_end_atis deliberately not guarded: the server validates only start against end, so blockingit client-side would refuse edits the server would accept.
3. Failure handling
The rows own their own outcome messages now;
updateItemandupdateMilestonereport a boolean andsay nothing. That is what makes the next two behaviours possible:
nothing else would move the field back —
DateTimePickerholds its own display state, hence theremount key.
flight, the user gets one verdict, from the last edit, rather than a failure followed by a success.
One condition — is anything pending at resolution? — drives both.
4. Form handlers out of Redux (
EventFormDialog,MilestoneFormDialog)showEventForm/showMilestoneFormstashed anonSubmitfunction in the store — not serialisable,silently tolerated in production, noisy in development, and a blocker for RTK's
serializableCheck.Both dialogs are now controlled by whoever opens them, taking
open,onClose,formTitle,initialValuesandonSubmitas props, with localsubmittingstate. Each opener renders its owndialog; the global mounts in
LessonPlanLayoutare gone. This followsCreateRenameTimelinePromptinthe reference-timelines bundle.
The operations previously closed the dialog themselves via
dispatch(actions.hideMilestoneForm())inside
.then(). Their.catch()swallows errors, so the returned promise resolved either way and thecaller could not tell success from failure. The four create/update operations are now
Operation<boolean>, and the dialog closes itself only on success — so a failed submit still staysopen with its field errors, exactly as before.
eventForm.jsandmilestoneForm.jsare deleted, along with theircombineReducersentries, fouraction creators and four action types. Nothing read them once the handlers moved.
5. TypeScript and
createSlice27 files converted. All the touched class components became function components using
useAppDispatch/useAppSelector/useTranslation, soconnect,injectIntl,PropTypesandFormattedMessageare gone from them — the last of those being the deprecation we are phasing out.Both remaining reducers are now
createSlice, matchingsubmission/reducers/historyandsubmission/reducers/scribing.flagsSlicepicks up the flags that arrive with the lesson plan fetchthrough
extraReducersonlessonPlanActions.loadSucceededrather than sharing a string constant.actionTypesis gone entirely, andconstants.tswith it: the edit-page columns are now aLESSON_PLAN_EDIT_COLUMNSconst array and aLessonPlanEditColumnunion, soeditPageColumnsVisibleis aRecord<LessonPlanEditColumn, boolean>and the column lookups arechecked properties rather than string indexing.
With the reducers typed,
combineReducersinfers the slice and every compatibility cast in thecomponents disappeared —
state.lessonPlan.lessonPlan.groupsand friends now type-check directly.6. Rendering cost
Converting
connectto hooks silently drops two things it provided for free, both fixed here:LessonPlanLayoutbriefly selected the wholelessonPlanslice, whose identity changes on any change within it — so every filter toggle and everyitem save re-rendered
<Outlet />and therefore the entire routed page. Selectors are now field byfield, and
ItemRowselectsvisibilityByType[type]as a boolean so only the rows whose own typechanged re-render.
React.memo.connect()wraps its component; a plain function component gets nothing.ItemRowand
MilestoneRoware memoised again — each row renders up to three MUI date pickers, so anunnecessary parent render is expensive.
Both dropdowns also mark their dispatch as a
startTransition, followingSearchField, so showing ahidden type does not block the click while its rows mount.
Behaviour changes worth reviewing
These are real changes, not pure refactors:
screen with only a toast to say it had not saved. This is the most visible change in the PR.
start_atno longer sends a request. It is required server-side, so an empty field is atransient state on the way to a new date rather than an update the server would accept. Previously it
PATCHed and 4xx'd. Applies to both items and milestones.
Previously it PATCHed, the server rejected it, and the user got a toast; now the field carries the
same message the event form shows, and the edit waits there until corrected.
createEvent/updateEventnow receivesetError. Their signatures always declared it, but theJS callers passed one argument short and silently dropped it — TypeScript caught this on conversion.
Server-side validation errors now reach the event form's fields, as they already did for milestones.
start_atno longer wipes the end dates. The oldDateCellcomputed the start-shiftunconditionally, so a cleared start produced
moment(null).diff(...)→NaN→nullforend_atand
bonus_end_attoo.AdminToolsguardslesson_plan_item_type?.[0]. MovinginitialValuesfrom dispatch-time intorender meant it evaluated on every render; any event item lacking that field would previously have
been fine until you clicked edit.
LessonPlanLayoutdropped achildrenprop that was declared required inpropTypesbut neverpassed or used — the component renders
<Outlet />. Its stale@ts-ignorein the router went too,now that the component no longer uses
connect.Testing
tsc --noEmit, eslint and prettier are clean. 33 tests pass across 11 suites in the bundle, up from24 across 9; the full client suite is green.
Each regression test was verified to fail without its fix rather than pass vacuously:
sends one request when a date is edited several times in quick succession— two rapid changes,asserts exactly one PATCH carrying the final value. This is the incident, in a test.
retries the same value after a failed save— fails the PATCH, asserts the field reverts, re-entersthe rejected value and asserts a second request goes out.
sends nothing while start date is empty, and sending resumes once it is valid.holds an end date that precedes the start date, and reports it— asserts the message appears andthat no request goes out even after the debounce window elapses; a sibling test asserts the edit is
sent once the ordering is corrected and the error clears.
flips — a round trip through the reducer, which is what a reducer-level test would have missed.
The four dialog tests got simpler: they used to render the opener and the dialog side by side and let
Redux connect them, which was the coupling itself.
Proving the absence of a request needs the debounce window to actually elapse, so two tests wait it
out via a
settleDebouncehelper. That adds roughly 4s to the suite. Fake timers would avoid it butinteract badly with RTL's
waitForand the MUI pickers.Notes / follow-ups
updateOrAppendmerges rather than replaces, and the slice preserves that. An item updatedispatches only the changed fields, so a straight replace would blank title and type on every save.
as unknown as Partial<AppState>remain, all in tests. Unrelated to the reducers:Partial<AppState>only permits omitting whole slices, and these tests seed a handful of fields.useDebouncecancels rather than flushes, so navigatingaway within 1500ms drops it. Inherited from the shared hook; the answers loop behaves the same way.
.jsxfiles remain in the bundle — the two form bodies,LessonPlanNav, and theLessonPlanShowsubtree.LocalizationProvider. Hoisting that provider out ofDateTimePickerwould cut it to one per page.Despite living in
lib/, that component has only two consumers, both in this bundle — so the changeis smaller than it looks, but it is still its own change.